/* !
 * Copyright 2012, Chris Wanstrath
 * Released under the MIT License
 * https://github.com/defunkt/jquery-pjax
 */

(function($) {

// When called on a container with a selector, fetches the href with
// ajax into the container or with the data-pjax attribute on the link
// itself.
//
// Tries to make sure the back button and ctrl+click work the way
// you'd expect.
//
// Exported as $.fn.pjax
//
// Accepts a jQuery ajax options object that may include these
// pjax specific options:
//
//
// container - String selector for the element where to place the response body.
//      push - Whether to pushState the URL. Defaults to true (of course).
//   replace - Want to use replaceState instead? That's cool.
//
// For convenience the second parameter can be either the container or
// the options object.
//
// Returns the jQuery object
  function fnPjax(selector, container, options) {
    options = optionsFor(container, options);
    return this.on('click.pjax', selector, function(event) {
      let opts = options;
      if (!opts.container) {
        opts = $.extend({}, options);
        opts.container = $(this).attr('data-pjax');
      }
      handleClick(event, opts);
    });
  }

// Public: pjax on click handler
//
// Exported as $.pjax.click.
//
// event   - "click" jQuery.Event
// options - pjax options
//
// Examples
//
//   $(document).on('click', 'a', $.pjax.click)
//   // is the same as
//   $(document).pjax('a')
//
// Returns nothing.
  function handleClick(event, container, options) {
    options = optionsFor(container, options);

    const link = event.currentTarget;
    const $link = $(link);

    if (link.tagName.toUpperCase() !== 'A') { throw '$.fn.pjax or $.pjax.click requires an anchor element'; }

  // Middle click, cmd click, and ctrl click should open
  // links in a new tab as normal.
    if (event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; }

  // Ignore cross origin links
    if (location.protocol !== link.protocol || location.hostname !== link.hostname) { return; }

  // Ignore case when a hash is being tacked on the current URL
    if (link.href.indexOf('#') > -1 && stripHash(link) == stripHash(location)) { return; }

  // Ignore event with default prevented
    if (event.isDefaultPrevented()) { return; }

    const defaults = {
      url: link.href,
      container: $link.attr('data-pjax'),
      target: link,
    };

    const opts = $.extend({}, defaults, options);
    const clickEvent = $.Event('pjax:click');
    $link.trigger(clickEvent, [ opts ]);

    if (!clickEvent.isDefaultPrevented()) {
      pjax(opts);
      event.preventDefault();
      $link.trigger('pjax:clicked', [ opts ]);
    }
  }

// Public: pjax on form submit handler
//
// Exported as $.pjax.submit
//
// event   - "click" jQuery.Event
// options - pjax options
//
// Examples
//
//  $(document).on('submit', 'form', function(event) {
//    $.pjax.submit(event, '[data-pjax-container]')
//  })
//
// Returns nothing.
  function handleSubmit(event, container, options) {
    options = optionsFor(container, options);

    const form = event.currentTarget;
    const $form = $(form);

    if (form.tagName.toUpperCase() !== 'FORM') { throw '$.pjax.submit requires a form element'; }

    const defaults = {
      type: ($form.attr('method') || 'GET').toUpperCase(),
      url: $form.attr('action'),
      container: $form.attr('data-pjax'),
      target: form,
    };

    if (defaults.type !== 'GET' && window.FormData !== undefined) {
      defaults.data = new FormData(form);
      defaults.processData = false;
      defaults.contentType = false;
    } else {
    // Can't handle file uploads, exit
      if ($form.find(':file').length) {
        return;
      }

    // Fallback to manually serializing the fields
      defaults.data = $form.serializeArray();
    }

    pjax($.extend({}, defaults, options));

    event.preventDefault();
  }

// Loads a URL with ajax, puts the response body inside a container,
// then pushState()'s the loaded URL.
//
// Works just like $.ajax in that it accepts a jQuery ajax
// settings object (with keys like url, type, data, etc).
//
// Accepts these extra keys:
//
// container - String selector for where to stick the response body.
//      push - Whether to pushState the URL. Defaults to true (of course).
//   replace - Want to use replaceState instead? That's cool.
//
// Use it just like $.ajax:
//
//   var xhr = $.pjax({ url: this.href, container: '#main' })
//   console.log( xhr.readyState )
//
// Returns whatever $.ajax returns.
  function pjax(options) {
    options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options);

    if ($.isFunction(options.url)) {
      options.url = options.url();
    }

    const hash = parseURL(options.url).hash;

    const containerType = $.type(options.container);
    if (containerType !== 'string') {
      throw "expected string value for 'container' option; got " + containerType;
    }
    const context = options.context = $(options.container);
    if (!context.length) {
      throw "the container selector '" + options.container + "' did not match anything";
    }

  // We want the browser to maintain two separate internal caches: one
  // for pjax'd partial page loads and one for normal page loads.
  // Without adding this secret parameter, some browsers will often
  // confuse the two.
    if (!options.data) options.data = {};
    if ($.isArray(options.data)) {
      options.data.push({ name: '_pjax', value: options.container });
    } else {
      options.data._pjax = options.container;
    }

    function fire(type, args, props) {
      if (!props) props = {};
      props.relatedTarget = options.target;
      const event = $.Event(type, props);
      context.trigger(event, args);
      return !event.isDefaultPrevented();
    }

    let timeoutTimer;

    options.beforeSend = function(xhr, settings) {
    // No timeout for non-GET requests
    // Its not safe to request the resource again with a fallback method.
      if (settings.type !== 'GET') {
        settings.timeout = 0;
      }

      xhr.setRequestHeader('X-PJAX', 'true');
      xhr.setRequestHeader('X-PJAX-Container', options.container);

      if (!fire('pjax:beforeSend', [ xhr, settings ])) { return false; }

      if (settings.timeout > 0) {
        timeoutTimer = setTimeout(function() {
          if (fire('pjax:timeout', [ xhr, options ])) { xhr.abort('timeout'); }
        }, settings.timeout);

      // Clear timeout setting so jquerys internal timeout isn't invoked
        settings.timeout = 0;
      }

      const url = parseURL(settings.url);
      if (hash) url.hash = hash;
      options.requestUrl = stripInternalParams(url);
    };

    options.complete = function(xhr, textStatus) {
      if (timeoutTimer) { clearTimeout(timeoutTimer); }

      fire('pjax:complete', [ xhr, textStatus, options ]);

      fire('pjax:end', [ xhr, options ]);
    };

    options.error = function(xhr, textStatus, errorThrown) {
      const container = extractContainer('', xhr, options);

      const allowed = fire('pjax:error', [ xhr, textStatus, errorThrown, options ]);
      if (options.type == 'GET' && textStatus !== 'abort' && allowed) {
        locationReplace(container.url);
      }
    };

    options.success = function(data, status, xhr) {
      const previousState = pjax.state;

    // If $.pjax.defaults.version is a function, invoke it first.
    // Otherwise it can be a static string.
      const currentVersion = typeof $.pjax.defaults.version === 'function' ?
      $.pjax.defaults.version() :
      $.pjax.defaults.version;

      const latestVersion = xhr.getResponseHeader('X-PJAX-Version');

      const container = extractContainer(data, xhr, options);

      const url = parseURL(container.url);
      if (hash) {
        url.hash = hash;
        container.url = url.href;
      }

    // If there is a layout version mismatch, hard load the new url
      if (currentVersion && latestVersion && currentVersion !== latestVersion) {
        locationReplace(container.url);
        return;
      }

    // If the new response is missing a body, hard load the page
      if (!container.contents) {
        locationReplace(container.url);
        return;
      }

      pjax.state = {
        id: options.id || uniqueId(),
        url: container.url,
        title: container.title,
        container: options.container,
        fragment: options.fragment,
        timeout: options.timeout,
      };

      if (options.push || options.replace) {
        window.history.replaceState(pjax.state, container.title, container.url);
      }

    // Only blur the focus if the focused element is within the container.
      const blurFocus = $.contains(context, document.activeElement);

    // Clear out any focused controls before inserting new page contents.
      if (blurFocus) {
        try {
          document.activeElement.blur();
        } catch (e) { /* ignore */ }
      }

      if (container.title) document.title = container.title;

      fire('pjax:beforeReplace', [ container.contents, options ], {
        state: pjax.state,
        previousState,
      });
      context.html(container.contents);

    // FF bug: Won't autofocus fields that are inserted via JS.
    // This behavior is incorrect. So if theres no current focus, autofocus
    // the last field.
    //
    // http://www.w3.org/html/wg/drafts/html/master/forms.html
      const autofocusEl = context.find('input[autofocus], textarea[autofocus]').last()[0];
      if (autofocusEl && document.activeElement !== autofocusEl) {
        autofocusEl.focus();
      }

      executeScriptTags(container.scripts);

      let scrollTo = options.scrollTo;

    // Ensure browser scrolls to the element referenced by the URL anchor
      if (hash) {
        const name = decodeURIComponent(hash.slice(1));
        const target = document.getElementById(name) || document.getElementsByName(name)[0];
        if (target) scrollTo = $(target).offset().top;
      }

      if (typeof scrollTo === 'number') $(window).scrollTop(scrollTo);

      fire('pjax:success', [ data, status, xhr, options ]);
    };


  // Initialize pjax.state for the initial page load. Assume we're
  // using the container and options of the link we're loading for the
  // back button to the initial page. This ensures good back button
  // behavior.
    if (!pjax.state) {
      pjax.state = {
        id: uniqueId(),
        url: window.location.href,
        title: document.title,
        container: options.container,
        fragment: options.fragment,
        timeout: options.timeout,
      };
      window.history.replaceState(pjax.state, document.title);
    }

  // Cancel the current request if we're already pjaxing
    abortXHR(pjax.xhr);

    pjax.options = options;
    const xhr = pjax.xhr = $.ajax(options);

    if (xhr.readyState > 0) {
      if (options.push && !options.replace) {
      // Cache current container element before replacing it
        cachePush(pjax.state.id, [ options.container, cloneContents(context) ]);

        window.history.pushState(null, '', options.requestUrl);
      }

      fire('pjax:start', [ xhr, options ]);
      fire('pjax:send', [ xhr, options ]);
    }

    return pjax.xhr;
  }

// Public: Reload current page with pjax.
//
// Returns whatever $.pjax returns.
  function pjaxReload(container, options) {
    const defaults = {
      url: window.location.href,
      push: false,
      replace: true,
      scrollTo: false,
    };

    return pjax($.extend(defaults, optionsFor(container, options)));
  }

// Internal: Hard replace current state with url.
//
// Work for around WebKit
//   https://bugs.webkit.org/show_bug.cgi?id=93506
//
// Returns nothing.
  function locationReplace(url) {
    window.history.replaceState(null, '', pjax.state.url);
    window.location.replace(url);
  }


  let initialPop = true;
  const initialURL = window.location.href;
  const initialState = window.history.state;

// Initialize $.pjax.state if possible
// Happens when reloading a page and coming forward from a different
// session history.
  if (initialState && initialState.container) {
    pjax.state = initialState;
  }

// Non-webkit browsers don't fire an initial popstate event
  if ('state' in window.history) {
    initialPop = false;
  }

// popstate handler takes care of the back and forward buttons
//
// You probably shouldn't use pjax on pages with other pushState
// stuff yet.
  function onPjaxPopstate(event) {

  // Hitting back or forward should override any pending PJAX request.
    if (!initialPop) {
      abortXHR(pjax.xhr);
    }

    const previousState = pjax.state;
    const state = event.state;
    let direction;

    if (state && state.container) {
    // When coming forward from a separate history session, will get an
    // initial pop with a state we are already at. Skip reloading the current
    // page.
      if (initialPop && initialURL == state.url) return;

      if (previousState) {
      // If popping back to the same state, just skip.
      // Could be clicking back from hashchange rather than a pushState.
        if (previousState.id === state.id) return;

      // Since state IDs always increase, we can deduce the navigation direction
        direction = previousState.id < state.id ? 'forward' : 'back';
      }

      const cache = cacheMapping[state.id] || [];
      const containerSelector = cache[0] || state.container;
      let container = $(containerSelector),
        contents = cache[1];

      if (container.length) {
        if (previousState) {
        // Cache current container before replacement and inform the
        // cache which direction the history shifted.
          cachePop(direction, previousState.id, [ containerSelector, cloneContents(container) ]);
        }

        const popstateEvent = $.Event('pjax:popstate', {
          state,
          direction,
        });
        container.trigger(popstateEvent);

        const options = {
          id: state.id,
          url: state.url,
          container: containerSelector,
          push: false,
          fragment: state.fragment,
          timeout: state.timeout,
          scrollTo: false,
        };

        if (contents) {
          container.trigger('pjax:start', [ null, options ]);

          pjax.state = state;
          if (state.title) document.title = state.title;
          const beforeReplaceEvent = $.Event('pjax:beforeReplace', {
            state,
            previousState,
          });
          container.trigger(beforeReplaceEvent, [ contents, options ]);
          container.html(contents);

          container.trigger('pjax:end', [ null, options ]);
        } else {
          pjax(options);
        }

      // Force reflow/relayout before the browser tries to restore the
      // scroll position.
        container[0].offsetHeight; // eslint-disable-line no-unused-expressions
      } else {
        locationReplace(location.href);
      }
    }
    initialPop = false;
  }

// Fallback version of main pjax function for browsers that don't
// support pushState.
//
// Returns nothing since it retriggers a hard form submission.
  function fallbackPjax(options) {
    let url = $.isFunction(options.url) ? options.url() : options.url,
      method = options.type ? options.type.toUpperCase() : 'GET';

    const form = $('<form>', {
      method: method === 'GET' ? 'GET' : 'POST',
      action: url,
      style: 'display:none',
    });

    if (method !== 'GET' && method !== 'POST') {
      form.append($('<input>', {
        type: 'hidden',
        name: '_method',
        value: method.toLowerCase(),
      }));
    }

    const data = options.data;
    if (typeof data === 'string') {
      $.each(data.split('&'), function(index, value) {
        const pair = value.split('=');
        form.append($('<input>', { type: 'hidden', name: pair[0], value: pair[1] }));
      });
    } else if ($.isArray(data)) {
      $.each(data, function(index, value) {
        form.append($('<input>', { type: 'hidden', name: value.name, value: value.value }));
      });
    } else if (typeof data === 'object') {
      let key;
      for (key in data) { form.append($('<input>', { type: 'hidden', name: key, value: data[key] })); }
    }

    $(document.body).append(form);
    form.submit();
  }

// Internal: Abort an XmlHttpRequest if it hasn't been completed,
// also removing its event handlers.
  function abortXHR(xhr) {
    if (xhr && xhr.readyState < 4) {
      xhr.onreadystatechange = $.noop;
      xhr.abort();
    }
  }

// Internal: Generate unique id for state object.
//
// Use a timestamp instead of a counter since ids should still be
// unique across page loads.
//
// Returns Number.
  function uniqueId() {
    return (new Date()).getTime();
  }

  function cloneContents(container) {
    const cloned = container.clone();
  // Unmark script tags as already being eval'd so they can get executed again
  // when restored from cache. HAXX: Uses jQuery internal method.
    cloned.find('script').each(function() {
      if (!this.src) $._data(this, 'globalEval', false);
    });
    return cloned.contents();
  }

// Internal: Strip internal query params from parsed URL.
//
// Returns sanitized url.href String.
  function stripInternalParams(url) {
    url.search = url.search.replace(/([?&])(_pjax|_)=[^&]*/g, '').replace(/^&/, '');
    return url.href.replace(/\?($|#)/, '$1');
  }

// Internal: Parse URL components and returns a Locationish object.
//
// url - String URL
//
// Returns HTMLAnchorElement that acts like Location.
  function parseURL(url) {
    const a = document.createElement('a');
    a.href = url;
    return a;
  }

// Internal: Return the `href` component of given URL object with the hash
// portion removed.
//
// location - Location or HTMLAnchorElement
//
// Returns String
  function stripHash(location) {
    return location.href.replace(/#.*/, '');
  }

// Internal: Build options Object for arguments.
//
// For convenience the first parameter can be either the container or
// the options object.
//
// Examples
//
//   optionsFor('#container')
//   // => {container: '#container'}
//
//   optionsFor('#container', {push: true})
//   // => {container: '#container', push: true}
//
//   optionsFor({container: '#container', push: true})
//   // => {container: '#container', push: true}
//
// Returns options Object.
  function optionsFor(container, options) {
    if (container && options) {
      options = $.extend({}, options);
      options.container = container;
      return options;
    } else if ($.isPlainObject(container)) {
      return container;
    }
    return { container };

  }

// Internal: Filter and find all elements matching the selector.
//
// Where $.fn.find only matches descendants, findAll will test all the
// top level elements in the jQuery object as well.
//
// elems    - jQuery object of Elements
// selector - String selector to match
//
// Returns a jQuery object.
  function findAll(elems, selector) {
    return elems.filter(selector).add(elems.find(selector));
  }

  function parseHTML(html) {
    return $.parseHTML(html, document, true);
  }

// Internal: Extracts container and metadata from response.
//
// 1. Extracts X-PJAX-URL header if set
// 2. Extracts inline <title> tags
// 3. Builds response Element and extracts fragment if set
//
// data    - String response data
// xhr     - XHR response
// options - pjax options Object
//
// Returns an Object with url, title, and contents keys.
  function extractContainer(data, xhr, options) {
    let obj = {},
      fullDocument = /<html/i.test(data);

  // Prefer X-PJAX-URL header if it was set, otherwise fallback to
  // using the original requested url.
    const serverUrl = xhr.getResponseHeader('X-PJAX-URL');
    obj.url = serverUrl ? stripInternalParams(parseURL(serverUrl)) : options.requestUrl;

    let $head,
      $body;
  // Attempt to parse response html into elements
    if (fullDocument) {
      $body = $(parseHTML(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0]));
      const head = data.match(/<head[^>]*>([\s\S.]*)<\/head>/i);
      $head = head != null ? $(parseHTML(head[0])) : $body;
    } else {
      $head = $body = $(parseHTML(data));
    }

  // If response data is empty, return fast
    if ($body.length === 0) { return obj; }

  // If there's a <title> tag in the header, use it as
  // the page's title.
    obj.title = findAll($head, 'title').last().text();

    if (options.fragment) {
      let $fragment = $body;
    // If they specified a fragment, look for it in the response
    // and pull it out.
      if (options.fragment !== 'body') {
        $fragment = findAll($fragment, options.fragment).first();
      }

      if ($fragment.length) {
        obj.contents = options.fragment === 'body' ? $fragment : $fragment.contents();

      // If there's no title, look for data-title and title attributes
      // on the fragment
        if (!obj.title) { obj.title = $fragment.attr('title') || $fragment.data('title'); }
      }

    } else if (!fullDocument) {
      obj.contents = $body;
    }

  // Clean up any <title> tags
    if (obj.contents) {
    // Remove any parent title elements
      obj.contents = obj.contents.not(function() { return $(this).is('title'); });

    // Then scrub any titles from their descendants
      obj.contents.find('title').remove();

    // Gather all script[src] elements
      obj.scripts = findAll(obj.contents, 'script[src]').remove();
      obj.contents = obj.contents.not(obj.scripts);
    }

  // Trim any whitespace off the title
    if (obj.title) obj.title = $.trim(obj.title);

    return obj;
  }

// Load an execute scripts using standard script request.
//
// Avoids jQuery's traditional $.getScript which does a XHR request and
// globalEval.
//
// scripts - jQuery object of script Elements
//
// Returns nothing.
  function executeScriptTags(scripts) {
    if (!scripts) return;

    const existingScripts = $('script[src]');

    scripts.each(function() {
      const src = this.src;
      const matchedScripts = existingScripts.filter(function() {
        return this.src === src;
      });
      if (matchedScripts.length) return;

      const script = document.createElement('script');
      const type = $(this).attr('type');
      if (type) script.type = type;
      script.src = $(this).attr('src');
      document.head.appendChild(script);
    });
  }

// Internal: History DOM caching class.
  var cacheMapping = {};
  const cacheForwardStack = [];
  const cacheBackStack = [];

// Push previous state id and container contents into the history
// cache. Should be called in conjunction with `pushState` to save the
// previous container contents.
//
// id    - State ID Number
// value - DOM Element to cache
//
// Returns nothing.
  function cachePush(id, value) {
    cacheMapping[id] = value;
    cacheBackStack.push(id);

  // Remove all entries in forward history stack after pushing a new page.
    trimCacheStack(cacheForwardStack, 0);

  // Trim back history stack to max cache length.
    trimCacheStack(cacheBackStack, pjax.defaults.maxCacheLength);
  }

// Shifts cache from directional history cache. Should be
// called on `popstate` with the previous state id and container
// contents.
//
// direction - "forward" or "back" String
// id        - State ID Number
// value     - DOM Element to cache
//
// Returns nothing.
  function cachePop(direction, id, value) {
    let pushStack,
      popStack;
    cacheMapping[id] = value;

    if (direction === 'forward') {
      pushStack = cacheBackStack;
      popStack = cacheForwardStack;
    } else {
      pushStack = cacheForwardStack;
      popStack = cacheBackStack;
    }

    pushStack.push(id);
    id = popStack.pop();
    if (id) delete cacheMapping[id];

  // Trim whichever stack we just pushed to to max cache length.
    trimCacheStack(pushStack, pjax.defaults.maxCacheLength);
  }

// Trim a cache stack (either cacheBackStack or cacheForwardStack) to be no
// longer than the specified length, deleting cached DOM elements as necessary.
//
// stack  - Array of state IDs
// length - Maximum length to trim to
//
// Returns nothing.
  function trimCacheStack(stack, length) {
    while (stack.length > length) { delete cacheMapping[stack.shift()]; }
  }

// Public: Find version identifier for the initial page load.
//
// Returns String version or undefined.
  function findVersion() {
    return $('meta').filter(function() {
      const name = $(this).attr('http-equiv');
      return name && name.toUpperCase() === 'X-PJAX-VERSION';
    }).attr('content');
  }

// Install pjax functions on $.pjax to enable pushState behavior.
//
// Does nothing if already enabled.
//
// Examples
//
//     $.pjax.enable()
//
// Returns nothing.
  function enable() {
    $.fn.pjax = fnPjax;
    $.pjax = pjax;
    $.pjax.enable = $.noop;
    $.pjax.disable = disable;
    $.pjax.click = handleClick;
    $.pjax.submit = handleSubmit;
    $.pjax.reload = pjaxReload;
    $.pjax.defaults = {
      timeout: 650,
      push: true,
      replace: false,
      type: 'GET',
      dataType: 'html',
      scrollTo: 0,
      maxCacheLength: 20,
      version: findVersion,
    };
    $(window).on('popstate.pjax', onPjaxPopstate);
  }

// Disable pushState behavior.
//
// This is the case when a browser doesn't support pushState. It is
// sometimes useful to disable pushState for debugging on a modern
// browser.
//
// Examples
//
//     $.pjax.disable()
//
// Returns nothing.
  function disable() {
    $.fn.pjax = function() { return this; };
    $.pjax = fallbackPjax;
    $.pjax.enable = enable;
    $.pjax.disable = $.noop;
    $.pjax.click = $.noop;
    $.pjax.submit = $.noop;
    $.pjax.reload = function() { window.location.reload(); };

    $(window).off('popstate.pjax', onPjaxPopstate);
  }


// Add the state property to jQuery's event object so we can use it in
// $(window).bind('popstate')
  if ($.event.props && $.inArray('state', $.event.props) < 0) {
    $.event.props.push('state');
  } else if (!('state' in $.Event.prototype)) {
    $.event.addProp('state');
  }

// Is pjax supported by this browser?
  $.support.pjax =
  window.history && window.history.pushState && window.history.replaceState &&
  // pushState isn't reliable on iOS until 5.
  !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]\D|WebApps\/.+CFNetwork)/);

  if ($.support.pjax) {
    enable();
  } else {
    disable();
  }

})(jQuery);
